package com.atguigu.gulimall.seckill.service.impl;

import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.TypeReference;
import com.atguigu.common.to.mq.SeckillOrderTo;
import com.atguigu.common.utils.R;
import com.atguigu.common.vo.MemberRespVo;
import com.atguigu.gulimall.seckill.feign.CouponFeignService;
import com.atguigu.gulimall.seckill.feign.ProductFeignService;
import com.atguigu.gulimall.seckill.interceptor.LoginUserInterceptor;
import com.atguigu.gulimall.seckill.service.SeckillService;
import com.atguigu.gulimall.seckill.to.SeckillSkuRedisTo;
import com.atguigu.gulimall.seckill.vo.SeckillSessionsWithSkus;
import com.atguigu.gulimall.seckill.vo.SeckillSkusVo;
import com.atguigu.gulimall.seckill.vo.SkuInfoVo;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import org.apache.commons.lang.StringUtils;
import org.redisson.api.RSemaphore;
import org.redisson.api.RedissonClient;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.beans.BeanUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.BoundHashOperations;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

import java.util.Date;
import java.util.List;
import java.util.Set;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

/**
 * @author gcq
 * @Create 2021-01-08
 */
@Service
public class SeckillServiceImpl implements SeckillService {

    /**
     * 秒杀活动信息前缀 key = seckill:sessions:startTime_endTime
     */
    private static final String SESSION_CACHE_PREFIX = "seckill:sessions:";
    /**
     * 保存活动信息关联的商品信息
     */
    private static final String SECKILL_CACHE_PREFIX = "seckill:skus:";
    /**
     * 分布式信号量
     * key = 前缀 + 商品随机码
     * value = 商品抢购库存数
     */
    private static final String SKU_STOCK_SEMAPHORE = "seckill:stock:";


    @Autowired
    CouponFeignService couponFeignService;

    @Autowired
    StringRedisTemplate redisTemplate;

    @Autowired
    ProductFeignService productFeignService;

    @Autowired
    RedissonClient redissonClient;

    @Autowired
    RabbitTemplate rabbitTemplate;

    /**
     * 扫描最近三天内需要上架的商品
     * 封装最新的秒杀商品信息保存到redis中
     * 1、保存活动关联的商品信息 到 redis中 seckill:skus
     * value hash结构 存储商品的详细数据信息
     * 2、保存活动信息 到redis seckill:sessions
     * key start _ endTime(活动开始和结束时间)
     * value:{skuid}(当前活动关联的所有商品)
     * 通过一个活动找到它所有关联的商品信息
     * 设置秒杀商品分布式信号量作为库存扣减信息
     * 1、seckill:stock:#{商品随机码}
     */
    @Override
    public void uploadSeckillSkuLatest3Days() {
        // 1、扫描三天内需要参加秒杀活动场次和商品信息
        R lates3DaySession = couponFeignService.getLates3DaySession();
        if (lates3DaySession.getCode() == 0) {
            // 请求成功
            List<SeckillSessionsWithSkus> sessionData = lates3DaySession.getData(new TypeReference<List<SeckillSessionsWithSkus>>() {
            });
            // 三天内需要参加秒杀活动信息不为空
            if (sessionData != null) {
                //把最新秒杀的上架商品数据缓存到redis，
                //缓存活动信息
                //先保存活动的信息到redis，比如最近三天都有哪些活动，方便进行检查，保存活动用的key就是开始时间和结束时间
                saveSessionInfos(sessionData);

                //缓存活动关联的商品信息
                //而值就是当前这个活动它里面所有关联的所有商品，就能按照一个活动能查询出它的所有关联的商品
                saveSessionSkuInfos(sessionData);
            }
        }
    }

    /**
     * 获取当前参加秒杀的商品信息
     * @return
     */
    @Override
    public List<SeckillSkuRedisTo> getCurrentSeckillSkus() {
        // 1、确定当前时间属于哪个场次?
        // 去redis里面查询一个当前时间需要参加秒杀的商品信息
        long time = new Date().getTime(); // 拿到当前时间信息
        // 获取活动场次的所有key
        Set<String> keys = redisTemplate.keys(SESSION_CACHE_PREFIX + "*");
        for (String key : keys) {
            // 将前缀替换为空串，方便取出时间
            String replace = key.replace(SESSION_CACHE_PREFIX, "");
            String[] s = replace.split("_"); // 分割以后开始计算时间
            long startTime = Long.parseLong(s[0]); // 开始时间
            long endTime = Long.parseLong(s[1]);// 结束时间
            // 验证当前时间是否在秒杀时间内
            if (time >= startTime && time <= endTime) { // 是在秒杀时间内
                // 拿到在key这个范围的全部活动场次
                List<String> range = redisTemplate.opsForList().range(key, -100, 100);
                // 查询这个范围所有的商品信息
                BoundHashOperations<String, String, Object> ops = redisTemplate.boundHashOps(SECKILL_CACHE_PREFIX);
                // 从绑定键的哈希值中获取给定{range}的值
                List<Object> list = ops.multiGet(range);
                if (list != null) {
                    // 拿到的秒杀商品信息转成RedisTo对象返回
                    List<SeckillSkuRedisTo> seckillSkuRedisToList = list.stream().map(item -> {
                        SeckillSkuRedisTo redisTo = JSON.parseObject((String) item, SeckillSkuRedisTo.class);
//                    redisTo.setRandomCode(null);
                        return redisTo;
                    }).collect(Collectors.toList());
                    return seckillSkuRedisToList;
                }
            } else { // 当前时间没有秒杀商品
                return null;
            }
        }
        // 2、获取这个秒杀场次需要的所有商品信息
        return null;
    }

    /**
     * 查询指定的skuid是否存在秒杀信息，如果存在该商品可以进入秒杀
     * 操作 seckill:skus: value 是hash类型，通过判断hash中key是否包含skuid，如果包含则取出该商品信息
     *
     * @param skuId
     * @return
     */
    @Override
    public SeckillSkuRedisTo getSkuSeckillInfo(Long skuId) {
        //活动信息关联的商品信息 key
        BoundHashOperations<String, String, Object> ops = redisTemplate.boundHashOps(SECKILL_CACHE_PREFIX);
        Set<String> keys = ops.keys(); // 拿到所有的key
        // 秒杀商品信息必须要存在
        if (keys != null && keys.size() > 0) {
            // //d代表匹配一个数字加上skuid
            String regx = "\\d_" + skuId;
            for (String key : keys) {
                // regx和key进行匹配如果匹配上说明hash中的key包含skuid
                if (Pattern.matches(regx, key)) { // 包含skuid
                    String json = (String) ops.get(key);// 取出skuid对应的商品信息
                    SeckillSkuRedisTo redisTo = JSON.parseObject(json, SeckillSkuRedisTo.class);
                    // 判断当前商品是否存在秒杀时间，不存在秒杀时间内将随机码置空，防止用户提前抢购
                    Long current = new Date().getTime();
                    if (current >= redisTo.getStartTime() && current <= redisTo.getEndTime()) {
                        // 存在秒杀时间里
                    } else { // 不存在秒杀时间内，说明该商品还没有参加秒杀
                        redisTo.setRandomCode(null); // 没参加秒杀，随机码置空
                    }
                    return redisTo;
                }
            }
        }
        return null;
    }

    /**
     * TODO 上架秒杀商品的时候，每一个数据都有过期时间
     * TODO 秒杀后续过程，简化了收货地址信息
     * 结合消息队列MQ，保证最终一致性，但是更多的是流量削峰，请求进来对应的大多数操作都是在reids在内存中完成的
     * 也不调用订单服务将数据写入到订单中（我认为他会降低接口的响应时间），
     * 而是直接放到MQ中，订单服务监听消息慢慢消费，从而缓解秒杀业务压力
     * @param killId 秒杀场次 id 和 skuid 的拼接 秒杀使用的id
     * @param key 随机码
     * @param num 购买数量
     * @return 返回订单号
     */
    @Override
    public String kill(String killId, String key, Integer num) {
        long s1 = System.currentTimeMillis();
        // 用户登录后数据
        MemberRespVo respVo = LoginUserInterceptor.loginUser.get();
        // 1、获取当前秒杀商品的详细信息
        BoundHashOperations<String, String, String> ops = redisTemplate.boundHashOps(SECKILL_CACHE_PREFIX);
        String s = ops.get(killId); // 获取秒杀商品信息
        if (StringUtils.isEmpty(s)) {
            return null;
        } else {
            // 转换成redisTo对象，并取出秒杀抢购开始时间 和 秒杀结束的时间 判断当前商品是不是可以进行抢购
            SeckillSkuRedisTo redisTo = JSON.parseObject(s, SeckillSkuRedisTo.class);
            // 合法性其校验性
            // 先检查秒杀时间是否过期
            Long startTime = redisTo.getStartTime(); // 开始时间
            Long endTime = redisTo.getEndTime();     // 结束时间
            Long currenttime = new Date().getTime(); // 当前时间
            long ttl = endTime - currenttime;        // 秒杀时间减去当前时间得到该商品活动秒杀结束时间
            // 如果说当前时间在我们的秒杀范围里
            if (currenttime >= startTime && currenttime <= endTime) { //那么该商品可以进行秒杀
                // 2、验证秒杀随机码，防止恶意用户提交
                String randomCode = redisTo.getRandomCode();
                String skuId = redisTo.getPromotionSessionId() + "_" + redisTo.getSkuId();
                if (randomCode.equals(key) && skuId.equals(killId)) {
                    // 3、验证数量购买是否合理，抢购商品对购买数量有限制
                    if (num <= redisTo.getSeckillLimit()) {
                        // 4、验证用户是否购买过，也就是秒杀商品是否购买,部分秒杀商品购买后不支持再次购买
                        String redisKey = respVo.getId() + "_" + skuId;
                        // 没有购买的话去占位置，最终会返回占位成功或占位失败，这个位置也不是永久占，当前场次一结束，redis中的数据自然也会过期
                        Boolean aBoolean = redisTemplate.opsForValue().setIfAbsent(redisKey, num.toString(), ttl, TimeUnit.MILLISECONDS);
                        if (aBoolean) { // 如果占位成功，说明此时这个请求没有购买这个商品
                            // 如果以上判断的验证都通过了，那么接下来就可以开始进行秒杀了，关于秒杀数量，
                            // 当前系统每一个商品的秒杀的件数都按照随机码保存了一个信号数字量，
                            // 只要正确的请求进来，这个信号量就减少一个，所以使用分布式信号量来进行操作，能减成功就说秒杀成功
                            RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + randomCode);
                            try {
                                // 从信号量中取出一个，但是我们要取多少个，得按照购买的数量来，
                                // 秒杀成功，进行快速下单，发送一个MQ消息
                                boolean b = semaphore.tryAcquire(num);
                                if (b) { // 只要信号量成了，才能做下面的操作
                                    String timeId = IdWorker.getTimeId(); // 创建一个订单号
                                    SeckillOrderTo seckillOrderTo = new SeckillOrderTo();
                                    seckillOrderTo.setSkuId(redisTo.getSkuId());
                                    seckillOrderTo.setPromotionSessionId(redisTo.getPromotionSessionId());
                                    seckillOrderTo.setNum(num);
                                    seckillOrderTo.setMemberId(respVo.getId());
                                    seckillOrderTo.setOrderSn(timeId);
                                    seckillOrderTo.setSeckillPrice(redisTo.getSeckillPrice());
                                    // 把订单信息发送给MQ，让MQ来负责订单操作
                                    rabbitTemplate.convertAndSend("order-event-exchange", "order.seckill.order", seckillOrderTo);
                                    long s2 = System.currentTimeMillis();
                                    System.out.println("耗时时间:" + (s2 - s1));
                                    // 拿到订单号，说明秒杀成功
                                    return timeId;
                                }
                                return null;
                            } catch (Exception e) {
                                e.printStackTrace();
                            }
                        }
                    }
                } else { // 验证码或活动场次id不正确
                    return null;
                }
            } else { //时间不合法
                return null;
            }
        }
        return null;
    }

    /**
     * 保存活动信息到redis中
     *
     * @param sessions
     */
    private void saveSessionInfos(List<SeckillSessionsWithSkus> sessions) {
        // 活动信息不能为空
        if (sessions != null) {
            sessions.stream().forEach(session -> {
                // redis存储数据 key:startTime _ endTime 开始时间和结束时间作为key
                Long startTime = session.getStartTime().getTime();
                Long endTime = session.getEndTime().getTime();
                /**
                 *  活动信息
                 *  key:加上秒杀活动信息开始时间和结束时间
                 *  value:{skuid}(当前活动场次【比如10点场关联了哪些商品信息】关联的所有商品(skuid))
                 */
                String key = SESSION_CACHE_PREFIX + startTime + "_" + endTime;
                // redis中是否存在该key 存在返回true 不存在返回false
                // 防止重复提交
                Boolean hasKey = redisTemplate.hasKey(key);
                // redis中没有这个key也就是当前没有活动场次信息，那么就需要进行添加，如果有这个场次那么就不用再进行添加
                if (!hasKey) { // 没有活动场次信息，进行添加
                    // value:list【getPromotionId_ skuid 活动场次_活动的商品skuid】
                    List<String> collect = session.getRelationSkus().stream().map(item -> item.getPromotionSessionId() + "_" + item.getSkuId().toString()).collect(Collectors.toList());
                    redisTemplate.opsForList().leftPushAll(key, collect); //以列表形式添加到redis中
                }
            });
        }
    }

    /**
     * 保存活动关联的商品信息到redis中
     *
     * @param sessions
     */
    private void saveSessionSkuInfos(List<SeckillSessionsWithSkus> sessions) {
        // 活动关联信息不能为空
        if (sessions != null) {
            sessions.stream().forEach(session -> {
                // 绑定hash操作
                BoundHashOperations<String, Object, Object> ops = redisTemplate.boundHashOps(SECKILL_CACHE_PREFIX);
                // 遍历活动关联的商品信息
                session.getRelationSkus().stream().forEach(seckillSkusVo -> {
                    //3.每一个商品要秒杀，就要一个随机码，这个随机码的作用就是防止被攻击，只要秒杀一开放，如果不加随机值，那么立马请求发出去，肯定就第一个抢到，
                    //所以引入随机码的目的就是想要来参加秒杀，若不知道这个随机码，那么就发请求也没什么用，这个随机码只有在秒杀开启的那一刻才暴露出来
                    //所以要为每一个商品设置上秒杀随机码
                    String randomCode = UUID.randomUUID().toString().replace("-", "");
                    // 活动关联的商品信息为空才进行添加，防止重复提交
                    if (!ops.hasKey(seckillSkusVo.getPromotionSessionId() + "_" + seckillSkusVo.getSkuId().toString())) {
                        SeckillSkuRedisTo redisTo = new SeckillSkuRedisTo();
                        // 1、sku基本数据
                        // 调用远程服务，根据skuid查询到当前商品信息
                        R r = productFeignService.getSkuInfoById(seckillSkusVo.getSkuId());
                        if (r.getCode() == 0) {
                            SkuInfoVo skuInfo = r.getData("skuInfo", new TypeReference<SkuInfoVo>() {
                            });
                            // 将sku商品信息放到redisTo中
                            redisTo.setSkuInfo(skuInfo);
                        }
                        // 2、sku的秒杀信息
                        // 将活动场次关联的商品信息拷贝到redisto
                        BeanUtils.copyProperties(seckillSkusVo, redisTo);

                        // 3、获取到商品抢购时间
                        redisTo.setStartTime(session.getStartTime().getTime());
                        redisTo.setEndTime(session.getEndTime().getTime());

                        /**
                         * 随机码，只有到了抢购时间，随机码才会发放
                         * // seckill?skuid=1&key=adasdf;
                         */
                        // 4、商品抢购验证码
                        redisTo.setRandomCode(randomCode);

                        // 3、秒杀随机码
                        String s = JSON.toJSONString(redisTo);
                        ops.put(seckillSkusVo.getPromotionSessionId() + "_" + seckillSkusVo.getSkuId().toString(), s);
                        //如果当前这个场次的商品的库存信息已经上架就不需要上架
                        /**
                         * .在redis里面设置信号量，这个信号量就算是一个自增量，只要高并发下的流量谁抢到了商品，信号量就减去抢到商品的数量
                         * 所以每一个商品都如果有了这个信号量信息，那么想要秒杀这个商品，系统会先去redis里面获取一个信号量，
                         * 也就是这个商品的100库存减去一个，如果能减了，就把你放行
                         * 再来做后边的处理数据库的商品库存信息扣减操作，如果不能减就不用去后边的数据库进行库存扣减，
                         * 那么这样就会阻塞很短的时间，整个请求就会得到很快的释放，只有每一个请求都很快的释放，能很快的处理完了才可以拥有处理大并发的能力。
                         */
                        //5.每一个商品都要设置分布式的信号量。也即使用库存作为分布式的信号量
                        //信号量的一大作用也是：限流
                        // 5、分布式信号量
                        RSemaphore semaphore = redissonClient.getSemaphore(SKU_STOCK_SEMAPHORE + randomCode);

                        /**
                         * 尝试设置许可证数量。也就是商品可以被抢购的数量
                         * 秒杀总量作为分布式信号量,商品有多少就放多少信号量，只要真正一个秒杀流量进来j就减去一个信号（可以理解为这个秒杀商品的库存）
                         */
                        semaphore.trySetPermits(seckillSkusVo.getSeckillCount().intValue());
                    }
                });
            });
        }
    }

}